SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
yesterdaylast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
10.1 KB · 209 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { Breadcrumbs } from "@/components/entity/breadcrumbs";5import { JsonLd } from "@/components/entity/json-ld";6import { Badge } from "@/components/ui/badge";7import { cn } from "@/components/ui/cn";8import { Card, CardBody, CardHeader, Page } from "@/components/ui/card";9import { LinkTabs } from "@/components/ui/tabs";10import { CoverageBar } from "@/components/intelligence/shared";11import { CountryFlag } from "@/components/ui/country-flag";12import { EmptyState, OfflineNotice } from "@/components/ui/states";13import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";14import { api } from "@/lib/api";15import { fmtDate, fmtDelta, fmtInt, fmtPct } from "@/lib/format";16import type { SearchParams } from "@/lib/sort";17import { routes } from "@/lib/routes";18import { NOT_FOUND_METADATA, absoluteUrl, pageMetadata } from "@/lib/seo";19import { SCOPE_LABEL, firstSentence, fmtRankValue, rowHref, secondaryLabel } from "../ranking-format";2021export const revalidate = 120;2223type Params = Promise<{ key: string }>;2425export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {26  const { key } = await params;27  const r = await api.ranking(key, { limit: 200 });28  if (!r.ok) return r.status === 404 || r.status === 400 ? NOT_FOUND_METADATA : { title: "Ranking" };29  const d = r.data;30  return pageMetadata({31    title: d.label,32    description: `Ranking of ${SCOPE_LABEL[d.scope].toLowerCase()} by ${d.label.toLowerCase()} — ${d.unit}. ${firstSentence(d.methodology)}`,33    path: routes.ranking(d.key),34  });35}3637export default async function RankingPage({ params, searchParams }: { params: Params; searchParams: Promise<SearchParams> }) {38  const [{ key }, sp] = await Promise.all([params, searchParams]);39  const mode = sp.mode === "all" ? "all" : "strict";40  const r = await api.ranking(key, { limit: 200, mode });41  if (!r.ok && (r.status === 404 || r.status === 400)) notFound();42  if (!r.ok) {43    return (44      <Page className="py-6">45        <Breadcrumbs items={[{ name: "Rankings", href: routes.rankings() }, { name: key, href: routes.ranking(key) }]} className="mb-4" />46        <OfflineNotice detail={r.error} />47      </Page>48    );49  }50  const d = r.data;51  const rows = d.rows ?? [];52  const showFlag = d.scope === "countries" || rows.some((x) => x.countryIso2);53  const hasSecondary = rows.some((x) => x.secondary !== null && x.secondary !== undefined);54  const hasCoverage = rows.some((x) => x.coverage !== null && x.coverage !== undefined);55  const hasDelta = rows.some((x) => x.delta !== null && x.delta !== undefined);56  const secLabel = secondaryLabel(d.methodology);5758  const jsonLd = {59    "@context": "https://schema.org",60    "@type": "ItemList",61    name: d.label,62    description: firstSentence(d.methodology),63    url: absoluteUrl(routes.ranking(d.key)),64    numberOfItems: rows.length,65    itemListOrder: "https://schema.org/ItemListOrderDescending",66    itemListElement: rows.slice(0, 100).map((row) => ({ "@type": "ListItem", position: row.rank, name: row.name, url: absoluteUrl(rowHref(row, d.scope)) })),67  };6869  return (70    <Page>71      <JsonLd data={jsonLd} />72      <Breadcrumbs items={[{ name: "Rankings", href: routes.rankings() }, { name: d.label, href: routes.ranking(d.key) }]} className="pt-4" />73      <header className="py-4 sm:py-6">74        <div className="label-caps mb-1.5">Ranking · {SCOPE_LABEL[d.scope]}</div>75        <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">{d.label}</h1>76        <div className="mt-2 flex flex-wrap items-center gap-2">77          <Badge tone="outline" mono>78            {d.unit}79          </Badge>80          <Badge tone="neutral">{SCOPE_LABEL[d.scope]}</Badge>81          <span className="figure text-[12px] text-ink-3">Computed {fmtDate(d.computedAt)}</span>82          {d.total > 0 && (83            <span className="figure text-[12px] text-ink-3">84              · {fmtInt(d.total)} {d.total === 1 ? "entity" : "entities"}85            </span>86          )}87        </div>88        {d.minCoverage !== undefined && d.minCoverage !== null && <p className="mt-2 max-w-2xl text-[12.5px] text-ink-3">Entities with fewer than 5 facilities or MW coverage below {fmtPct(d.minCoverage)} are excluded, not estimated.</p>}89        <p className="mt-1.5 max-w-2xl text-[12px] text-ink-4">Known / published figures only — entities without a figure are absent, not estimated.</p>90      </header>9192      {d.minCoverage !== undefined && d.minCoverage !== null && (93        <div className="mb-4">94          <LinkTabs95            items={[96              { href: routes.ranking(d.key), label: "Strict", active: mode === "strict", count: d.total },97              { href: `${routes.ranking(d.key)}?mode=all`, label: "All observed", active: mode === "all" },98            ]}99          />100          <p className="mt-2 text-[11.5px] text-ink-3">101            <strong className="font-medium text-ink-2">Strict</strong> = coverage gate applied (≥ 5 facilities, ≥ {fmtPct(d.minCoverage)} with a figure). <strong className="font-medium text-ink-2">All observed</strong> lists every entity with at least one published figure regardless of coverage; the coverage column shows how incomplete each row is. Nothing is estimated in either view.102          </p>103        </div>104      )}105106      {rows.length === 0 ? (107        <EmptyState108          title="No entity meets the coverage gate yet"109          description={110            d.minCoverage !== undefined && d.minCoverage !== null111              ? `This ranking only lists entities with at least 5 facilities and MW coverage of ${fmtPct(d.minCoverage)} or more. Nothing is estimated to fill the table; rows appear as primary sources publish capacity figures.`112              : "Rows appear once the worker has computed this ranking from indexed facilities. Nothing is estimated to fill the table."113          }114          action={115            <Link href={routes.rankings()} className="text-[13px] text-ink underline decoration-hair-2 hover:text-accent">116              All rankings117            </Link>118          }119        />120      ) : (121        <Table dense mobileCollapse={{ total: rows.length, label: "rows" }}>122          <THead>123            <tr>124              <Th numeric className="w-12">125                #126              </Th>127              <Th>{d.scope === "countries" ? "Country" : d.scope === "metros" ? "Market" : d.scope === "operators" ? "Operator" : "Facility"}</Th>128              <Th numeric>{d.unit}</Th>129              {hasSecondary && (130                <Th numeric className="hidden md:table-cell">131                  {secLabel}132                </Th>133              )}134              {hasCoverage && <Th className="w-[140px]">Coverage</Th>}135              {hasDelta && (136                <Th numeric className="hidden sm:table-cell">137                  Δ rank138                </Th>139              )}140            </tr>141          </THead>142          <TBody>143            {rows.map((row) => (144              <Tr key={row.id}>145                <Td label="Rank" numeric className="text-ink-3">146                  {row.rank}147                </Td>148                <Td primary>149                  <span className="inline-flex items-center gap-2">150                    {showFlag && row.countryIso2 && <CountryFlag iso2={row.countryIso2} size={13} />}151                    <RowLink href={rowHref(row, d.scope)}>{row.name}</RowLink>152                  </span>153                </Td>154                <Td label={d.unit} numeric lead>155                  {fmtRankValue(row.value, d.unit)}156                </Td>157                {hasSecondary && (158                  <Td label={secLabel} numeric className="hidden text-ink-3 md:table-cell">159                    {row.secondary === null || row.secondary === undefined ? "—" : fmtInt(row.secondary)}160                  </Td>161                )}162                {hasCoverage && (163                  <Td label="Coverage">164                    {row.coverage === null || row.coverage === undefined ? (165                      <span className="text-ink-4">—</span>166                    ) : (167                      <CoverageBar value={row.coverage} width="w-full sm:w-[120px]" label="Share of facilities with a MW figure" />168                    )}169                  </Td>170                )}171                {hasDelta && (172                  <Td label="Δ rank" numeric className="hidden sm:table-cell">173                    {row.delta === null || row.delta === undefined ? <span className="text-ink-4">new</span> : <span className={cn(row.delta > 0 ? "text-ok" : row.delta < 0 ? "text-danger" : "text-ink-3")}>{fmtDelta(row.delta)}</span>}174                  </Td>175                )}176              </Tr>177            ))}178          </TBody>179        </Table>180      )}181182      <div className="mt-8 grid gap-4 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">183        <Card>184          <CardHeader level={2} title="Methodology" description={`Unit: ${d.unit} · scope: ${SCOPE_LABEL[d.scope].toLowerCase()}`} />185          <CardBody>186            <p className="text-[13.5px] leading-relaxed text-ink-2">{d.methodology}</p>187          </CardBody>188        </Card>189        <Card>190          <CardHeader level={2} title="How to read this" />191          <CardBody className="text-[12.5px] leading-relaxed text-ink-2">192            <p>Values are lower bounds computed from published or filed figures only. Coverage is the share of an entity&apos;s facilities carrying any MW figure; a low coverage means the value understates reality.</p>193            <p className="mt-2">Δ rank compares with the previous computation of the same ranking; “new” marks an entity that was not ranked before.</p>194            <p className="mt-3">195              <Link href={routes.rankings()} className="text-ink underline decoration-hair-2 hover:text-accent">196                All rankings197              </Link>198              <span className="text-ink-4"> · </span>199              <Link href={`${routes.methodology()}#rankings`} className="text-ink underline decoration-hair-2 hover:text-accent">200                Methodology201              </Link>202            </p>203          </CardBody>204        </Card>205      </div>206    </Page>207  );208}209